<?php
/**
 * KePHP, Keep PHP easy!
 *
 * @license   https://opensource.org/licenses/MIT
 * @copyright Copyright 2015-2018 KePHP Authors All Rights Reserved
 * @link      http://kephp.com/utils ( https://git.oschina.net/kephp/kephp-utils )
 * @author    曾建凯 <janpoem@163.com>
 */

namespace Ke\Utils;

/**
 * 路径处理助手
 *
 * 这个的方法方法实现，参数为 `$ds` 的，表明该参数必须是目录分隔符，也即 `PathHelper::DS_WIN` 或 `PathHelper::DS_UNIX` 其中之一。
 *
 * 参数命名为 `$spr` ，表明该参数可以指定除了目录分隔符以外的字符串，但因为处理机制的问题，该参数只支持 1 位长度字符串，超出的会截取掉保留首位的字符串。
 *
 * @package Ke\Utils
 */
class PathHelper
{
	
	/** @var string 默认的路径处理时常见的噪音字符 */
	const DEFAULT_PATH_NOISE = '/\\ ';
	
	/** @var string Windows 风格的目录分隔符 */
	const DS_WIN = '\\';
	/** @var string Unix 或者 Linux 风格的目录分隔符 */
	const DS_UNIX = '/';
	
	/** @var int 净化路径值时，.././ 删除 - 默认值 */
	const DOT_REMOVE = 0b00;
	/** @var int 净化路径值时，.././ 维持原来状态 */
	const DOT_ORIGINAL = 0b10;
	/** @var int 净化路径值时，.././ 常规化 - ../ 向前递进一个目录 */
	const DOT_NORMALIZE = 0b11;
	
	/** @var int 净化路径值时，最左边（开头）的目录分隔符 删除 - 默认值 */
	const LEFT_REMOVE = 0b0000;
	/** @var int 净化路径值时，最左边（开头）的目录分隔符 维持原状 */
	const LEFT_ORIGINAL = 0b1000;
	/** @var int 净化路径值时，最左边（开头）的目录分隔符 强制填充 */
	const LEFT_FILL = 0b1100;
	
	/** @var int 净化路径值时，最右边（末尾）的目录分隔符 删除 - 默认值 */
	const RIGHT_REMOVE = 0b000000;
	/** @var int 净化路径值时，最右边（末尾）的目录分隔符 维持原状 */
	const RIGHT_ORIGINAL = 0b100000;
	/** @var int 净化路径值时，最右边（末尾）的目录分隔符 强制填充 */
	const RIGHT_FILL = 0b110000;
	
	const LR_REMOVE = 0b00;
	/** @var int 净化路径值时，头尾的目录分隔符 维持原状 */
	const LR_ORIGINAL = self::LEFT_ORIGINAL | self::RIGHT_ORIGINAL;
	/** @var int 净化路径值时，头尾的目录分隔符 强制填充 */
	const LR_FILL = self::LEFT_FILL | self::RIGHT_FILL;
	
	/** @var int 净化路径值时，全部有效的二进制取值 */
	const PURGE_ALL_VALUE = 0b111111;
	
	/** @var int 默认的 purge 处理模式，该参数预留给实际项目中重载时，调节参数使用。 */
	protected $purgeMode = self::DOT_REMOVE | self::LEFT_REMOVE | self::RIGHT_REMOVE;
	
	/** @var string 默认的目录分隔符 */
	protected $ds = self::DS_UNIX;
	
	/** @var string 类实例默认的路径噪音字符，这个 $noise 可被后继的类重载，使用上和 `PathHelper::DEFAULT_PATH_NOISE` 不同 */
	protected $noise = self::DEFAULT_PATH_NOISE;
	
	/** @var array 已经生成的 `路径 => 绝对路径` 的映射，这里会存在 访问名不同（绝对路径或相对路径），但绝对路径的值相同可能性，所以以访问的路径名作为 key */
	protected $pathMaps = [];
	
	/**
	 * 生成一个路径的绝对路径（php的realpath方法）
	 *
	 * - 调整默认 `realpath` 函数不支持 phar 包内文件的结果。
	 * - 统一将所有的目录分隔符转化为统一的目录分隔符（根据 `$this->ds`）。
	 *
	 * @todo 在unix下，不存在的路径是否应该返回false
	 *
	 * @param string $path
	 *
	 * @return string
	 */
	public function absolute(string $path): string
	{
		if (!isset($this->pathMaps[$path])) {
			$realPath = realpath($path);
			if ($realPath === false) {
				if (file_exists($path)) {
					$realPath = $path;
				}
			}
			if ($realPath !== false) {
				if (strpos($realPath, self::DS_WIN) !== false)
					$realPath = str_replace(self::DS_WIN, self::DS_UNIX, $realPath);
			}
			$this->pathMaps[$path] = $realPath;
		}
		return $this->pathMaps[$path];
	}
	
	/**
	 * 净化路径值
	 *
	 * 1. 根据指定的 $spr 替换为统一的目录分隔符
	 * 2. 去掉多余重复的 $spr
	 * 3. 依据 $dotMode 过滤（或不处理） 路径值中的 /../
	 * 4. 依据 $leftMode 是否补充（或强制填充，或强制截取） 最开始的目录分隔符（尤其是对于绝对路径），Windows风格的路径值不受该参数影响。
	 *
	 * 基于上一个版本的kephp的全局函数 purge_path 方法，优化了 $mode 参数。
	 *
	 * 去掉了默认的 urldecode ，所以处理 Url 的路径时，请自行先行进行 decode。
	 *
	 * $mode 用于说明一个路径净化处理时的三个处理模式：
	 *
	 * - dotMode - 路径中的 ../ ./ 的处理模式：
	 *     - `PathHelper::DOT_REMOVE`    - 默认值，强制删除路径中点，忽略其意义
	 *     - `PathHelper::DOT_ORIGINAL`  - 维持原状，不做处理。
	 *     - `PathHelper::DOT_NORMALIZE` - 常规化处理，即将 ../ 向前一层的目录递进， ./ 则删除。
	 * - leftMode & rightMode - 路径头尾的目录分隔符的处理方式：
	 *     - `PathHelper::LEFT_REMOVE` `PathHelper::RIGHT_REMOVE`     - 默认值，强制去除最左边或最右边的分隔符
	 *     - `PathHelper::LEFT_ORIGINAL` `PathHelper::RIGHT_ORIGINAL` - 最左边或最右边的分隔符维持原状（原来有就有，原来没有就没有）
	 *     - `PathHelper::LEFT_FILL` `PathHelper::RIGHT_FILL`         - 最左边或最右边的分隔符强制填充（原来没有也会强制加上）
	 *
	 * `PathHelper::LR_REMOVE   = PathHelper::LEFT_REMOVE   | PathHelper::RIGHT_REMOVE  ` - 左右两边都删除
	 * `PathHelper::LR_ORIGINAL = PathHelper::LEFT_ORIGINAL | PathHelper::RIGHT_ORIGINAL` - 左右两边都维持原状
	 * `PathHelper::LR_FILL     = PathHelper::LEFT_FILL     | PathHelper::RIGHT_FILL    ` - 左右两边都填充
	 *
	 * ```php
	 * $mode = PathHelper::DOT_ORIGINAL | PathHelper::LR_REMOVE; // 路径中的点维持原状，左右两边的分隔符删除
	 * $mode = PathHelper::DOT_REMOVE | PathHelper::LR_FILL; // 路径中的点强制删除，左右两边的分隔符强制填充
	 * ```
	 *
	 * 调用示例：
	 *
	 * ```php
	 * $helper = new PathHelper();
	 * $helper->purge('a/b/c', PathHelper::LR_FILL); // => /a/b/c/
	 * $helper->purge('-a---b---..--.---c-d---', PathHelper::DOT_REMOVE | PathHelper::LR_REMOVE， '-'); // => a-b-c-d
	 * ```
	 *
	 * 更多示例代码，请参阅 `Test_PathHelper` 。
	 *
	 * 该方法支持通过 $spr 指定其他的分隔符，但分隔符只支持 1 位长度字符串，如果超出，会只取该字符的 0 的字符值作为 $spr。
	 *
	 * @param string      $path      要净化处理的路径值
	 * @param int|null    $mode      净化的处理模式，参考 PathHelper 的常量说明
	 * @param string|null $spr       目录分隔符，可以指定其他的分隔符
	 * @param string|null $trimNoise 路径处理的噪音字符串
	 *
	 * @return string
	 */
	public function purge(string $path, int $mode = null, string $spr = null, string $trimNoise = null): string
	{
		if (!isset($mode) || $mode < 0)
			$mode = $this->purgeMode;
		
		$mode      = $mode & PathHelper::PURGE_ALL_VALUE;
		$rightMode = ($mode >> 4) << 4;
		$leftMode  = (($mode ^ $rightMode) >> 2) << 2;
		$dotMode   = $mode ^ $rightMode ^ $leftMode;
		
		// 过滤$spr，基于spr来确定noise
		if (empty($spr)) {
			$spr = $this->ds;
		} else if (mb_strlen($spr) > 0) {
			// 只取 $spr 的第一个字符
			$spr = mb_substr($spr, 0, 1);
		}
		
		// 这里要使用常量，不能使用类变量
		if (empty($trimNoise)) $trimNoise = self::DEFAULT_PATH_NOISE;
		// 检查spr是否在noise中
		if (strpos($trimNoise, $spr) === false) $trimNoise .= $spr;
		
		// 其次，基于spr如果是目录分隔符，替换掉多余的winDS或者unixDS，确保路净只存在一种目录分隔符
		if ($spr === self::DS_UNIX) {
			$path = str_replace(self::DS_WIN, self::DS_UNIX, $path);
		} else if ($spr === self::DS_WIN) {
			$path = str_replace(self::DS_UNIX, self::DS_WIN, $path);
		}
		
		$isWinPath      = false; // 是否windows风格的路径
		$winHead        = null; // windows路径头部
		$isStartWithSpr = false; // 是否以spr为开头
		$isEndWithSpr   = mb_substr($path, -1) === $spr; // 是否以spr为结尾
		
		if ($isWinPath = preg_match('#^([a-z]\:)[\/\\\\]#i', $path, $matches)) {
			$size           = mb_strlen($matches[1]);
			$winHead        = mb_substr($path, 0, $size); // 提取出 D:
			$path           = mb_substr($path, $size);
			$isStartWithSpr = true; // 符合windows风格的路径名，必然是绝对路径
		} else {
			$isStartWithSpr = mb_substr($path, 0, 1) === $spr;
		}
		
		// 去掉路径两边的多余的噪音字符，这里必须确保，只有一个/处理为空字符
		$path = trim($path, $trimNoise);
		
		// dot 处理
		if (!empty($path)) {
			if ($dotMode === self::DOT_ORIGINAL) { // .././ 维持原状，不做处理
				$path = preg_replace('#[' . preg_quote($spr) . ']+#', $spr, $path);
			} else {
				$temp = [];
				foreach (explode($spr, $path) as $index => $segment) {
					// 这些都去掉
					if ($segment === '.' || $segment === $spr || empty($segment))
						continue;
					if ($segment === '..') {
						if ($dotMode === self::DOT_NORMALIZE)
							array_pop($temp);
						continue;
					}
					$temp[] = $segment;
				}
				$path = implode($spr, $temp);
			}
		}
		
		// 最后基于 leftMode or winStyle 重新还原路径
		if ($isWinPath) {
			$path = $winHead . $spr . $path;
		} else {
			if ($leftMode === self::LEFT_FILL || ($leftMode === self::LEFT_ORIGINAL && $isStartWithSpr)) {
				$path = $spr . $path;
			}
		}
		
		if ($path !== $spr && ($rightMode === self::RIGHT_FILL || ($rightMode === self::RIGHT_ORIGINAL && $isEndWithSpr))) {
			$path .= $spr;
		}
		
		return $path;
	}
	
	/**
	 * 转换路径中的目录分隔符
	 *
	 * 该方法不支持 Unix 或 Windows 风格以外的目录分隔符。
	 *
	 * @param string      $path 要转换的路径
	 * @param string|null $ds   目录分隔符，这里只允许是 Unix 或 Windows 风格的目录分隔符，不支持其他。
	 *
	 * @return string 返回统一转换过的路径名
	 */
	public function convertDirectorySeparator(string $path, string $ds = null): string
	{
		if (empty($path))
			return '';
		if (empty($ds) || ($ds !== self::DS_UNIX && $ds !== self::DS_WIN))
			$ds = $this->ds;
		$search = $ds === self::DS_UNIX ? self::DS_WIN : self::DS_UNIX;
		if (strpos($path, $search) !== false)
			$path = str_replace($search, $ds, $path);
		return $path;
	}
	
	/**
	 * 指定一个路径，为该路径预备建立所需的目录（递归）
	 *
	 * - 如果该路径是一个文件路径，则建立该文件所需的目录（dirname）。
	 * - 如果是一个目录路径，则建立整个路径。
	 *
	 * @param string $path  一个要写入的文件路径，或者是一个目录的路径
	 * @param bool   $isDir 说明 $path 是一个文件路径还是一个目录路径
	 * @param int    $mode  创建目录的权限值
	 *
	 * @return false|string 返回所创建的（或者本身目录已经存在的）目录的绝对路径，如果创建失败或者传入的路径名有误，返回 false
	 */
	public function prepareDirectory(string $path, bool $isDir = false, $mode = 0755): string
	{
		$dir = $isDir ? $path : dirname($path);
		if (!empty($dir) && $dir !== '.' && $dir !== self::DS_WIN && $dir !== self::DS_UNIX) {
			if (!is_dir($dir)) {
				if (!mkdir($dir, $mode, true)) {
					return false;
				}
			}
			return $this->absolute($dir);
		}
		return false;
	}
	
	/**
	 * 将一个路径分离（解析）出目录名、文件名、文件后缀名（强制转小写）、无后缀文件名
	 *
	 * - 该方法不包含purge，请先自行purge。
	 * - 该方法也不会统一转换目录的分隔符，请先自行转换。
	 * - 该方法解析路径时，以最右边（末尾）是否为一个目录分隔符，作为识别该路径是否是一个目录的路径，还是一个文件的路径。目录路径时，文件名、文件后缀名、无后缀文件名皆为 null。
	 * - 该方法提取 文件后缀名 的规则，为文件名部分最右边的 `.` 之后（不含 `.` ）的字符串
	 * - 提取出的 文件后缀名 ，会强制转为小写保存（但解析出的 文件名 不会做此处理）
	 *
	 * ```php
	 * path('/var/log/'); // => 表示为一个目录路径，结果：['/var/log', null, null, null]
	 * path('/var/log'); // => 表示为一个文件路径，结果：['/var', ‘log’, null, 'log']
	 * path('/var/log/nginx.log'); // => 表示为一个文件路径，结果：['/var/log', ‘nginx.log’, 'log', 'nginx']
	 *
	 * // 文件名不会进行大小写转换处理，但是提取出来的后缀文件名，会强制转为小写
	 * path('/var/log/nginx.LOG'); // => 表示为一个文件路径，结果：['/var/log', ‘nginx.LOG’, 'log', 'nginx']
	 *
	 * // 后缀名匹配，为右匹配的模式
	 * path('/var/log/nginx.20180930.log'); // => 表示为一个文件路径，结果：['/var/log', ‘nginx.20180930.log’, 'log', 'nginx.20180930']
	 * ```
	 *
	 * 注意：由于风格的问题，`dirname` 强制去除了最末尾的 目录分隔符。
	 *
	 * @todo 后续会为PathHelper添加一个类属性，用于定制路径的处理风格。
	 *
	 * @param string $path
	 *
	 * @return array 返回数据格式：`[dirname, filename, extname, basename]`
	 */
	public function split(string $path): array
	{
		$return = [
			null, // dirname  - 目录名
			null, // filename - 文件名
			null, // extname  - 文件后缀名
			null, // basename - 无后缀文件名
		];
		
		if ($path !== '') {
			if (preg_match('#^(?:(.*)[\/\\\\])?([^\/\\\\]+)?$#', $path, $matches)) {
				if (!empty($matches[1])) {
//					$return[0] = preg_replace('#(\/+)$#', '', $matches[1]);
					$return[0] = rtrim($matches[1], $this->noise); // 目录
				}
				if (isset($matches[2]) && $matches[2] !== '') {
					$return[1] = $matches[2]; // 文件名
					if (($pos = strrpos($matches[2], '.')) > 0) {
						// 文件后缀名
						$return[2] = strtolower(substr($matches[2], $pos + 1));
						// 去除后缀名的文件名
						$return[3] = substr($matches[2], 0, $pos);
					} else {
						$return[3] = $matches[2];
					}
				}
			}
		}
		
		return $return;
	}
	
	/**
	 * 侦测一个路径是否为包含phar协议的路径值
	 *
	 * @param string $path
	 *
	 * @return array 返回结果 `[移除phar的路径名, 是否phar]`
	 */
	public function detectPhar(string $path): array
	{
		$isPhar = false;
		if (preg_match('#^phar://(.*)[\/\\\\]([^\/\\\\]+\.phar)#i', $path, $matches)) {
			$path   = $matches[1];
			$isPhar = $matches[2];
		} else if (preg_match('#^phar://(.*)#i', $path, $matches)) {
			$path = $matches[1];
		}
		return [$path, $isPhar];
	}
	
	/**
	 * 比较两个路径，返回相同的部分
	 *
	 * 必须确保两个传入的路径都是被净化处理过的路径名，不包含类如/../，并且请确保传入的路径都有一致的目录分隔符。
	 * 本函数不会自动调用purge的函数，请调用前自己执行
	 *
	 * ```php
	 * compare_path('/aa/bb/cc', '/aa/bb/dd'); // => aa/bb
	 *
	 * // 这个函数还可以用于挑出两个字符串相同的部分
	 * compare_path('ab-cd-ef-gh-ij', 'ab-cd-ef-gh-abc', '-'); // => ab-cd-ef-gh
	 * ```
	 *
	 * @param string|null $source
	 * @param string|null $target
	 * @param string|null $delimiter
	 * @param string|null $noise
	 * @param string|null $prefix
	 *
	 * @return string
	 */
	public function compare(
		string $source = null
		,
		string $target = null
		,
		string $delimiter = null
		,
		string $prefix = null
		,
		string $noise = null
	): string {
		// 处理分隔符
		if (empty($delimiter)) {
			$delimiter = $this->ds;
		} else if (mb_strlen($delimiter) > 0) {
			$delimiter = mb_substr($delimiter, 0, 1); // 只取 $delimiter 的第一个字符
		}
		// 噪音
		if (empty($noise))
			$noise = $this->noise;
		// 补充一下噪音
		$noise .= $delimiter;
		
		// 过滤 $prefix
		$prefix = $prefix ?? '';
		// if (!empty($prefix))
		// 	$prefix = trim($source, $noise);
		// 不对prefix进行噪音过滤处理，所以请确保传入的prefix的准确性
		
		if (empty($source) || empty($target))
			return $prefix;
		
		$source = trim($source, $noise);
		$target = trim($target, $noise);
		
		$result      = [];
		$splitSource = explode($delimiter, $source);
		$splitTarget = explode($delimiter, $target);
		if (!empty($splitSource) && !empty($splitTarget)) {
			foreach ($splitSource as $index => $str) {
				if (!isset($splitSource[$index]) ||
					!isset($splitTarget[$index]) ||
					strcasecmp($splitSource[$index], $splitTarget[$index]) !== 0
				) {
					break;
				}
				$result[] = $str;
			}
		}
		
		if (!empty($result))
			return $prefix . implode($delimiter, $result);
		
		return $prefix;
	}
}